The Complete Evolution

Android App Architecture

From tangled Activities to Clean Architecture — a deep journey through every pattern that shaped modern Android development.

Scroll to explore
Architecture Timeline — 2008 to Present
08
Early
~2008
MVC
MVC
~2010
MVP
MVP
~2013
VM
MVVM
~2017
MVI
MVI
~2019
CA
Clean
~2021
00
The Origin

The Wild West
No Architecture

When Activities did everything — and nothing was tested

Android 1.0 — ~2008 to 2012

Android launched in 2008 with a simple premise: Activity is your app. Developers coming from desktop or web backgrounds had no established mobile architecture guidelines. An Activity handled UI rendering, user input, business logic, network calls, database operations — everything poured into a single God class.

This era was characterized by "God Activities" — single files that could span thousands of lines, doing UI manipulation, HTTP requests, SQLite queries, and calculations all in one place. There was no separation of concerns, no testability, and rotating the screen would destroy your Activity and wipe any in-progress state.

The God Activity Pattern
Activity / Fragment
UI rendering + event handling + business logic + database access + network calls + state management — all in one class
The Good Parts
  • Simple mental model for small apps
  • Fast to prototype, easy to get started
  • No boilerplate or extra classes
  • Direct access to everything from one place
The Problems
  • Massive, untestable God classes
  • Zero separation of concerns
  • Configuration changes destroy state
  • Impossible to unit test UI logic
  • Code reuse is practically impossible
  • Bugs cascade across unrelated features

Why developers sought a better way

As apps grew in complexity, "God Activities" became maintenance nightmares. Teams needed a way to separate what the app looks like from what it does. MVC offered the first real answer — borrowed from web development.

01
Architecture Pattern

Model — View
Controller

The first serious attempt to bring structure to Android

Adopted ~2010 — 2014

🔧 Problem it solved from the previous era

The "God Activity" problem was so severe that developers started borrowing MVC from web frameworks (Rails, Django, Spring MVC). The goal: split the monolith into three distinct roles so that business logic no longer lived inside UI code. It was the first serious separation of concerns for Android apps.

MVC divides the app into three layers: Model (data and business rules), View (UI representation, often XML layouts), and Controller (the Activity/Fragment that bridges the two). When a user interacts with the View, it notifies the Controller. The Controller processes it, updates the Model, and the Model updates the View.

On Android, the mapping was awkward from the start. Activities are neither pure Controllers nor pure Views — they touch both. The XML layouts became the "View," the Activity became the "Controller," and plain Java/Kotlin classes became "Models." This blurry boundary meant the Activity still grew large, even if less chaotic.

MVC — Layer Breakdown
Model
Data classes, repositories, business logic. Notifies observers of state changes. No Android dependencies.
↕ updates / queries
Controller (Activity/Fragment)
Receives user input, calls Model methods, instructs View on what to display. The "glue" between Model and View.
↕ user events / render commands
View (XML Layouts + custom Views)
Passive UI. Displays data sent by Controller. Can directly reference Controller to send events back.
How it flows

Data Flow

User taps button → View notifies Controller → Controller calls Model → Model updates data → Model notifies Controller → Controller updates View. Simple linear flow, but in Android the View can also directly access the Controller since the Activity is both.

Android reality

The Mapping Problem

Android's Activity violates pure MVC because it's responsible for both View inflation and Controller logic. You can't cleanly separate them. The XML is the "View" but the Activity controls it, making Activity still massive — just better organised.

Pros
  • First real separation of concerns for Android
  • Business logic in Model is unit-testable
  • Familiar pattern for web developers
  • Simple to understand conceptually
  • Reduced God Activity somewhat
Cons
  • Activity is still massive — it's both View and Controller
  • View and Controller tightly coupled
  • Impossible to unit test Controller (Activity needs Android framework)
  • View can directly reference Model — fragile two-way dependencies
  • Configuration changes (rotation) still destroy Controller + state
  • No clean answer for handling async operations

What drove the move to MVP

MVC's fatal flaw on Android: the Controller (Activity) has tight Android framework dependencies, making it untestable with JUnit. Developers needed a way to test the "controller logic" without a device. MVP solved this by introducing an interface contract between Presenter and View, making the Presenter fully mockable and testable.

02
Architecture Pattern

Model — View
Presenter

Testability first — the Activity finally becomes truly passive

Dominant ~2013 — 2018

🔧 Problems it solved from MVC

MVC's Controller (Activity) was inseparable from Android's framework — you couldn't test it with plain JUnit. MVP drew a strict interface contract between the Presenter and the View. The Activity implements a View interface, the Presenter holds a reference to that interface (not the Activity itself). Now you can mock the View in tests and write real unit tests for all presentation logic. This was Android testing's first big leap.

In MVP, the View (Activity/Fragment) is deliberately dumb — it only renders data pushed to it and delegates all user events to the Presenter. The Presenter contains all UI logic and calls Model. Critically, the View and Presenter communicate only through a defined interface (contract), making both sides independently replaceable and testable.

The Presenter holds a reference to the View interface. When the Activity is destroyed (rotation), the Presenter must detach the View reference to avoid memory leaks. This explicit lifecycle management became a common source of bugs and boilerplate — every screen needed a View interface, a Presenter class, and careful attach/detach logic.

MVP — Layer Breakdown
View (Activity/Fragment — implements IView interface)
Only inflates XML, displays data, delegates ALL user events to Presenter. Has no logic. Implements IView.
↕ IView interface calls (fully mockable)
Presenter (pure Kotlin/Java class)
All UI logic lives here. Calls Model, gets results, decides what View should show. Zero Android imports → 100% unit testable.
↕ data queries / callbacks
Model (Repository + Data Sources)
Business logic, local DB, network API. Returns data to Presenter via callbacks or Rx streams.
The key innovation

Interface Contract

Every screen defines an IView interface (e.g., showUserList(), showError(), showLoading()). The Activity implements it. The Presenter only knows the interface — never the Activity. Mock the interface in tests and the Presenter becomes fully testable with JUnit.

The tradeoff

Boilerplate Explosion

Every feature requires: an IView interface, a Presenter class, an IPresenter interface (optionally), plus careful attachView() / detachView() calls in every Activity lifecycle method. Complex screens accumulate massive interface definitions.

Pros
  • Presenter is 100% unit-testable — no Android framework needed
  • View is fully passive — clear role separation
  • Easy to mock View in tests via interface
  • Clear contract between View and Presenter
  • Logic can be reused across Views implementing same interface
Cons
  • Massive boilerplate — interface for every screen
  • Manual View attach/detach leads to memory leaks if forgotten
  • 1-to-1 Presenter-to-View coupling — hard to share logic
  • Configuration changes still recreate Activity, Presenter must survive separately
  • Async handling (callbacks, RxJava) complicates Presenter significantly
  • Presenter itself becomes a new "God class" for complex screens

Why MVVM replaced MVP as the standard

Google introduced Architecture Components in 2017 — specifically ViewModel + LiveData. ViewModel survives configuration changes natively, eliminating the manual attach/detach lifecycle dance. LiveData is lifecycle-aware, automatically stopping updates when the View is off-screen. This made MVVM dramatically safer and less boilerplate-heavy than MVP, and Google officially recommended it.

03
Google Recommended

Model — View
ViewModel

Lifecycle-awareness, no memory leaks, officially blessed by Google

Architecture Components 2017 — Still widely used today

🔧 Problems it solved from MVP

MVP required developers to manually call presenter.attachView(this) and presenter.detachView() in every Activity — forget once and you had a memory leak or a crash. MVVM's ViewModel is lifecycle-aware by design: it survives rotation and is automatically cleared when the screen is permanently destroyed. LiveData/StateFlow only emits to active observers, so no null pointer crashes from posting to a destroyed View. All that manual lifecycle glue code vanished.

MVVM's ViewModel holds and exposes UI state via LiveData or StateFlow. The View (Activity/Fragment) observes these streams — it reacts to state changes rather than being instructed step by step. There is no callback or method call from ViewModel back to the View; data flows one direction: ViewModel → View via reactive streams.

The ViewModel has no reference to the View at all, making memory leaks structurally impossible. When combined with Data Binding, the XML itself can observe LiveData directly, further reducing Activity boilerplate. With Kotlin Coroutines and viewModelScope, async operations are automatically cancelled when the ViewModel is cleared — handling the async problem MVP never solved cleanly.

MVVM — Layer Breakdown
View (Activity/Fragment/Composable)
Observes LiveData/StateFlow. Sends user events to ViewModel. Never holds business logic. Survives rotation by resubscribing.
↑ observes state (no reverse reference)
ViewModel (survives config changes)
Holds UI state in LiveData/StateFlow. Processes user intents. Calls Repository. Zero View references. Lives in viewModelScope for coroutines.
↕ requests data / receives results
Model (Repository Pattern)
Single source of truth. Abstracts local DB (Room) and remote API (Retrofit). Returns Flow or suspend functions to ViewModel.
Key innovation

Lifecycle Awareness

ViewModel is scoped to the UI lifecycle — it survives rotation but is cleared when the screen closes. LiveData/StateFlow are lifecycle-aware observers. You literally cannot leak a View reference from a ViewModel, since ViewModel has no View reference at all.

Modern companion

Jetpack Compose + MVVM

Compose's collectAsState() pairs perfectly with StateFlow. The ViewModel emits a single UiState data class, Compose recomposes reactively. This is Google's current gold-standard pattern for new Android apps in 2024.

The async revolution

Coroutines + viewModelScope

Every coroutine launched in viewModelScope is automatically cancelled when the ViewModel is cleared. No more RxJava CompositeDisposable management. No more manually cancelling network requests. Async code reads like synchronous code with suspend fun, and structured concurrency handles cleanup for you.

Pros
  • ViewModel survives configuration changes natively
  • No memory leaks — ViewModel never holds View reference
  • Lifecycle-aware data streams (LiveData/StateFlow)
  • Much less boilerplate than MVP
  • Official Google recommendation with full Jetpack support
  • Coroutines + viewModelScope make async clean
  • Pairs perfectly with Jetpack Compose
Cons
  • Multiple LiveData/StateFlow properties can make state management complex
  • State is scattered across multiple observable fields — harder to reason about
  • Events (navigation, toasts) are tricky — LiveData re-delivers on rotation
  • No strict unidirectional data flow — View can still call ViewModel methods freely
  • Difficult to reproduce exact UI states for debugging
  • Data Binding with LiveData can lead to hard-to-trace XML errors

Why MVI emerged alongside MVVM

MVVM's weakness: state is scattered across multiple LiveData fields. A screen with 5 different data properties has 5 separate streams — inconsistent states (loading spinner + data showing simultaneously) become possible. MVI solves this with a single sealed UiState and strict Unidirectional Data Flow, making state predictable, reproducible, and debuggable.

04
Reactive Pattern

Model — View
Intent (MVI)

One state to rule them all — Redux for Android

Popularized ~2019 — Present (especially with Compose)

🔧 Problems it solved from MVVM

MVVM often has multiple LiveData/StateFlow properties exposed — isLoading, userList, errorMessage as three separate fields. This allows impossible states: what if isLoading = true AND userList is non-empty AND errorMessage is non-null simultaneously? MVI enforces a single sealed UiState (Loading | Success | Error) — only one valid state exists at any moment, making impossible states structurally impossible.

MVI is inspired by Redux (JavaScript) and Elm architecture. The core principle is Unidirectional Data Flow (UDF): the View emits Intents (user actions), the ViewModel processes them via a Reducer (a pure function that takes current state + intent → new state), and emits a single State back to the View. The flow is strictly one direction.

Because state is a single immutable data class (or sealed class), you can snapshot state at any point in time. This enables powerful features: logging every state transition for debugging, time-travel debugging, writing deterministic tests by simply feeding a state + intent → asserting the next state. Side effects (navigation, toasts) are handled separately as Effects/Events via Channel.

MVI — Strict Unidirectional Data Flow
View — observes UiState, emits UiEvent (Intents)
Renders a single UiState. User interactions become Intent events dispatched to ViewModel. Never modifies state directly.
↓ Intent (user action) → one direction only
ViewModel — Reducer + State Holder
Receives Intent → calls Model → runs Reducer (pure function: State + Intent → new State) → emits new UiState. Side effects go to UiEffect Channel.
↓ new UiState emitted (one direction only)
Model — Repository + Use Cases
Returns data to ViewModel. Stateless from MVI's perspective — ViewModel owns the single source of truth for UI state.
↑ UiState flows back up (separate reactive stream)
Core concept

Single UiState Sealed Class

Instead of isLoading: Boolean + data: List + error: String, you have one type: sealed class UiState { Loading, Success(data), Error(msg) }. Impossible combinations are impossible by the type system itself.

Superpower

Reproducible State

Every state your app has ever been in is a serializable data object. Log them all. Replay them. Write tests that say "given state X, when user sends Intent Y, assert new state Z." No mocking, no Mockito — pure functional assertions.

One-way events

UiEffect for Side Effects

Navigation, SnackBars, and Toast messages are side effects — they happen once and aren't part of persistent state. MVI handles these separately via Channel (consumed exactly once), solving the MVVM problem of LiveData re-delivering events on rotation.

Compose fit

Perfect Compose Pair

Compose's recomposition model aligns perfectly with MVI — a single state object drives the entire UI tree. State hoisting, collectAsState(), and sealed UiState classes make MVI the natural architecture for Compose-first apps.

Pros
  • Single source of truth — impossible states are structurally prevented
  • Strict unidirectional flow — easy to reason about and debug
  • Fully reproducible UI states — incredible for testing and logging
  • Side effects handled cleanly via Channel (consumed exactly once)
  • Pairs naturally with Jetpack Compose
  • Reducer is a pure function — the easiest unit tests ever written
Cons
  • More boilerplate than MVVM — Intent classes, State classes, Effect classes needed
  • Steeper learning curve — especially the UDF mental model
  • Copying state objects for every update can be expensive (use data classes wisely)
  • Overkill for simple screens with minimal state
  • No official Google library — community conventions vary (Orbit, MVI Kotlin, etc.)

The bigger picture — Clean Architecture

MVI and MVVM answer how UI talks to logic. But neither answers how to organize the entire codebase — the domain layer, data sources, use cases, and dependency rules. Clean Architecture provides the macro-level structure that any of these UI patterns sit on top of.

05
Current Standard

Clean Architecture
+ MVVM / MVI

Uncle Bob's rings meet Android — the definitive modern structure

~2014 (Uncle Bob) → Android adoption 2018 — Present

🔧 Problems it solved that MVVM/MVI alone couldn't

MVVM and MVI tell you how the UI layer works. But where do you put business logic that has nothing to do with UI? Where does domain knowledge live — "a user can only checkout if their cart is non-empty" — that is a rule that doesn't belong in a ViewModel nor in a Repository. Clean Architecture introduces a Domain Layer with Use Cases for exactly this. It also enforces the Dependency Rule: inner layers (domain) must never know about outer layers (UI, database). This makes the business logic completely independent of Android, databases, and frameworks.

Clean Architecture (Robert C. Martin, 2012) organizes code into concentric rings: Entities (core business objects) → Use Cases / Interactors (application-specific business rules) → Interface Adapters (Presenters/ViewModels, Repositories) → Frameworks & Drivers (Android, Room, Retrofit, UI). The fundamental rule: source code dependencies point only inward. Nothing in an inner ring knows anything about outer rings.

For Android, this typically maps to three modules: Presentation (Activities, Fragments, ViewModels, Composables), Domain (Use Cases, Repository interfaces, Domain Models — zero Android dependencies), and Data (Repository implementations, Room DAOs, Retrofit APIs, Mappers). The Domain module is pure Kotlin — it can be tested with JUnit alone, on any JVM, with zero Android SDK.

Clean Architecture — The Three Layers for Android
Presentation Layer
Activities · Fragments · Composables · ViewModels · MVI State/Intent/Effect
Knows about: Domain. Android framework allowed here.
↓ calls Use Cases (via interfaces only)
Domain Layer — the sacred core ✦
Use Cases (Interactors) · Domain Models · Repository Interfaces
Zero Android imports. Pure Kotlin. The most stable, most valuable code.
↓ implements Repository interfaces (Dependency Inversion)
Data Layer
Repository Implementations · Room DAOs · Retrofit Services · DataStore · Mappers
Knows about: Domain interfaces only. Depends inward, never outward.
The sacred rule

The Dependency Rule

Domain layer imports nothing from Android, Room, or Retrofit. It defines interfaces. The Data layer implements those interfaces. The Presentation layer calls Use Cases. Dependencies point inward — Domain is the king, it knows nothing of its servants.

Why Use Cases?

Single Responsibility Business Logic

Each Use Case is one class, one responsibility: GetUserUseCase, CheckoutUseCase, ValidateEmailUseCase. They are reusable across multiple ViewModels, contain pure business rules, and are testable with a single mock of the Repository interface.

Dependency Inversion

The Repository Interface Pattern

Domain defines interface UserRepository { suspend fun getUser(id: String): User }. Data module provides UserRepositoryImpl using Room + Retrofit. At test time, inject a FakeUserRepository. Domain never changes when you swap SQLite for Firestore.

Module structure

Multi-Module Projects

Clean Architecture naturally maps to Gradle modules: :app, :domain, :data, and feature modules. The :domain module has no Android dependencies — it compiles fast and tests instantly. This structure also enables better build times at scale.

Pros
  • Domain layer is 100% framework-independent — the most testable code possible
  • Business rules survive framework changes (swap Room for Firestore — Domain unchanged)
  • Use Cases are single-responsibility, reusable, composable
  • Clear boundaries — junior devs know exactly where to put new code
  • Scales to large teams — layers can be owned by different squads
  • Combines perfectly with MVVM or MVI for the UI layer
  • The current industry standard recommended by Google's official guides
Cons
  • Significant upfront boilerplate — many classes for a simple feature
  • Overkill for small personal projects or prototypes
  • Steep learning curve — requires understanding of DI (Hilt/Dagger)
  • More files to navigate — can feel over-engineered initially
  • Mappers between Domain/Data/Presentation models add repetitive code
  • Wrong layer placement mistakes are common early on

Side-by-Side Comparison

Every architecture at a glance — pick what fits your project's needs

Pattern Era Testability State Management Boilerplate Config Change Best For
No Arch 2008–2012 ❌ Nearly impossible Ad-hoc in Activity None (but chaos) ❌ Destroy & recreate Prototypes only
MVC 2010–2014 ⚠️ Model only Controller manages it Low ❌ Controller re-created Simple apps, learning
MVP 2013–2018 ✅ Presenter testable Presenter owns it High (interfaces) ⚠️ Manual save/restore Legacy codebases
MVVM 2017–Present ✅ ViewModel testable Multiple LiveData/Flow Medium ✅ ViewModel survives Most Android apps today
MVI 2019–Present ✅✅ Reducer is pure fn Single sealed UiState Medium-High ✅ ViewModel survives Complex state, Compose
Clean + MVVM/MVI 2021–Present ✅✅✅ Every layer MVVM or MVI on top Very High ✅✅ Full lifecycle Production apps, teams